Skip to content

Fix phpstan/phpstan#14446: Incorrect type-narrowing when using strict rules#5435

Open
phpstan-bot wants to merge 8 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-0461d07
Open

Fix phpstan/phpstan#14446: Incorrect type-narrowing when using strict rules#5435
phpstan-bot wants to merge 8 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-0461d07

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

When polluteScopeWithAlwaysIterableForeach is false (enabled by strict rules), PHPStan incorrectly narrowed variables that were aliased via assignment. For example, $current = $initial followed by if (!$current) would incorrectly narrow $initial to false after a foreach loop reassigned $current, because the alias relationship was not properly invalidated.

Changes

  • src/Analyser/MutatingScope.php: In processAlwaysIterableForeachScopeWithoutPollute(), changed from using $this->conditionalExpressions (pre-foreach) to $finalScope->conditionalExpressions (post-foreach body). This ensures that conditional expressions invalidated by assignments inside the foreach body (like $current = false) are not incorrectly preserved.
  • Added regression test tests/PHPStan/Analyser/Bug14446Test.php with test data and config.

Root cause

processAlwaysIterableForeachScopeWithoutPollute() takes expression types from $finalScope (the scope after the foreach body runs) but was using conditional expressions from $this (the scope before the foreach). When $current = $initial creates a conditional expression linking the two variables, and then $current = false inside a foreach invalidates that link in $finalScope, the method was still using the old (stale) conditional expressions from $this. This caused $initial to be incorrectly narrowed to false when $current was false (from the foreach reassignment), even though the alias was no longer valid.

In contrast, when polluteScopeWithAlwaysIterableForeach is true, the generalizeWith() path widens the type of $current back to bool, so the conditional expression never fires — masking the issue.

Test

Added tests/PHPStan/Analyser/Bug14446Test.php which uses polluteScopeWithAlwaysIterableForeach: false config and verifies that $initial remains bool throughout the function — at the top of the while loop, inside the if (!$current) block, and after the while loop exits.

Fixes phpstan/phpstan#14446

- Fixed processAlwaysIterableForeachScopeWithoutPollute using stale
  conditional expressions from before the foreach body, which caused
  variable aliases (e.g. $current = $initial) to persist even after
  reassignment inside the foreach
- Changed to use $finalScope->conditionalExpressions which properly
  reflects invalidated aliases from foreach body assignments
- New regression test in tests/PHPStan/Analyser/data/bug-14446.php

Closes phpstan/phpstan#14446
Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This produce the unwanted error

Foreach overwrites $key with its key variable.     

with the code

if (is_array($value) && $value !== []) {
			$hasOnlyStringKey = true;
			foreach (array_keys($value) as $key) {
				if (is_int($key)) {
					$hasOnlyStringKey = false;
					break;
				}
			}

			if ($hasOnlyStringKey) {
				$keyTypes = [];
				$valueTypes = [];
				foreach ($value as $key => $element) {
					$keyType = $scope->getTypeFromValue($key);
					$keyStringTypes = $keyType->getConstantStrings();
					if (count($keyStringTypes) !== 1) {
						throw new ShouldNotHappenException();
					}
					$keyTypes[] = $keyStringTypes[0];
					$valueTypes[] = $this->generalizeTypeFromValue($scope, $element);
				}

				return ConstantArrayTypeBuilder::createFromConstantArray(
					new ConstantArrayType($keyTypes, $valueTypes),
				)->getArray();
			}

			return new ArrayType(
				TypeCombinator::union(...array_map(fn ($item): Type => $this->generalizeTypeFromValue($scope, $item), array_keys($value))),
				TypeCombinator::union(...array_map(fn ($item): Type => $this->generalizeTypeFromValue($scope, $item), array_values($value))),
			);
		}

Since $key is only defined in the foreach loop the error shouldn't be reported with pollute false

…ableForeachScopeWithoutPollute

When using $finalScope->conditionalExpressions, conditional expressions
referencing variables only defined inside the foreach body (like $key)
could leak into the outer scope. When those conditional expressions fired
(e.g. inside an if block), the foreach variable would be re-introduced
with Yes certainty, causing false "Foreach overwrites $key" errors.

Filter conditional expressions to only keep those where both the target
expression and all condition expressions reference variables that existed
before the foreach loop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Pushed. Here's what I did:

Problem: The PR's change to use $finalScope->conditionalExpressions caused conditional expressions referencing foreach-only variables (like $key) to leak into the outer scope. When those conditional expressions fired inside a subsequent if block, the foreach variable was re-added to scope with Yes certainty, causing a false "Foreach overwrites $key with its key variable" error.

Fix (src/Analyser/MutatingScope.php:3873-3892): Filter $finalScope->conditionalExpressions to only keep entries where:

  1. The target expression exists in $this->expressionTypes (pre-foreach scope)
  2. All condition expression holders reference expressions that exist in $this->expressionTypes

This preserves the original bug 14446 fix (stale conditional expressions from $this are not used) while preventing foreach-body-only variables from leaking via conditional expressions.

Test: Added testForeachKeyOverwrite() to the test data file reproducing the reviewer's exact scenario — two foreach loops with $key, where the second should not trigger the overwrite warning.

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This now produce

------ ---------------------------------------------------------------------------------------------------- 
  Line   src\Analyser\NodeScopeResolver.php                                                                  
 ------ ---------------------------------------------------------------------------------------------------- 
  437    Call to an undefined method PhpParser\Node::getReturnType().                                        
         🪪  method.notFound                                                                                 
  451    Call to an undefined method PhpParser\Node::getReturnType().                                        
         🪪  method.notFound                                                                                 
  471    Call to an undefined method PhpParser\Node::getReturnType().                                        
         🪪  method.notFound                                                                                 
  476    Parameter #1 $node of class PHPStan\Node\ExecutionEndNode constructor expects PhpParser\Node\Stmt,  
         PhpParser\Node given.                                                                               
         🪪  argument.type                                                                                   
 ------ ---------------------------------------------------------------------------------------------------- 

So I assume your fix is not correct

…copeWithoutPollute

The previous approach (using $finalScope->conditionalExpressions filtered
to $this variables) lost valid conditional expressions that existed in
$this but not in $finalScope, causing false positives like "Call to an
undefined method PhpParser\Node::getReturnType()".

The correct approach: start from $this->conditionalExpressions (pre-foreach)
and remove only entries where a condition variable's type changed between
$this and $finalScope, indicating it was reassigned in the foreach body
and the conditional expression is stale. When a type change is detected,
the entire conditional expression key is removed (not individual holders)
to avoid partial/inconsistent state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Pushed the fix. Here's a summary of the change:

Problem: The previous approach used $finalScope->conditionalExpressions (post-foreach) as the base, which lost valid conditional expressions that existed before the foreach but weren't preserved in $finalScope. This caused false positives like Call to an undefined method PhpParser\Node::getReturnType() when PHPStan analyzed its own codebase with strict rules (polluteScopeWithAlwaysIterableForeach: false).

Fix: Start from $this->conditionalExpressions (pre-foreach) and only remove entries where a condition variable's type changed between $this and $finalScope. A type change indicates the variable was reassigned inside the foreach body, making the conditional expression stale. When detected, the entire key is removed (not individual holders) to avoid partial/inconsistent state.

Verification:

  • make phpstan - no errors (was 4 errors before)
  • make tests - all 11826 tests pass
  • Bug 14446 regression test passes

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix

FILE: src/Analyser/MutatingScope.php
--------------------------------------------------------------------------------
FOUND 1 ERROR AFFECTING 1 LINE
--------------------------------------------------------------------------------
 3876 | ERROR | Unused variable $condTypeHolder.
      |       | (SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable)
--------------------------------------------------------------------------------

…opeWithoutPollute

Use array_keys() to iterate only over keys since the value is not used.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Fixed the unused variable $condTypeHolder by changing the foreach to iterate over array_keys($holder->getConditionExpressionTypeHolders()) instead. Both make phpstan and the regression test pass.

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without modifying the code, explain to me why there was no issue with

function test(bool $initial): void {
    $current = $initial;
	
    while ($bool) {
		dumpType($initial);
        if (!$current) {
			dumpType($initial);
            break;
        }

        $items = [1];
        foreach ($items as $item) {
            $current = false;
        }
    }

	dumpType($initial);
    var_dump($initial === true);
}

but an issue with while(true) and how you get a consistent behavior

Why for example we're not using the same code for both $isIterableAtLeastOnce->maybe() and !$this->polluteScopeWithAlwaysIterableForeach

…opeWithoutPollute

Replace custom conditional expression filtering with
intersectConditionalExpressions($finalScope->conditionalExpressions),
which is the same mechanism mergeWith() uses in the
$isIterableAtLeastOnce->maybe() code path.

When the foreach body reassigns a variable (e.g. $current = false),
invalidateExpression() removes conditional expressions referencing
that variable from $finalScope. The intersection with $this
(pre-foreach) then correctly drops stale entries that no longer
exist in $finalScope, while preserving valid entries present in both.

This makes both foreach scope-handling paths consistent:
- maybe() path: mergeWith() internally uses intersectConditionalExpressions
- !polluteScopeWithAlwaysIterableForeach path: now also uses
  intersectConditionalExpressions

Also adds a test for the maybe-iterable case to verify consistent
behavior regardless of whether the foreach is always iterable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

The GitHub token doesn't have permission to comment on the upstream PR. Let me summarize what was done:

Changes made

src/Analyser/MutatingScope.php: Replaced the custom 17-line conditional expression filtering loop in processAlwaysIterableForeachScopeWithoutPollute with a single call to $this->intersectConditionalExpressions($finalScope->conditionalExpressions). This is the same mechanism that mergeWith() uses in the $isIterableAtLeastOnce->maybe() code path.

tests/PHPStan/Analyser/data/bug-14446.php: Added testMaybeIterable() to verify consistent behavior when the foreach iterable might be empty (rand() > 0 ? [1] : []).

Answer to the reviewer's questions

Why while($bool) doesn't show the issue: With while(true), the final scope comes only from break exit points (stale conditional already fired). With while($bool), the final scope merges with the "loop didn't enter" scope where $initial is still bool, masking the incorrect narrowing.

Why $isIterableAtLeastOnce->maybe() already works: When $current = false runs in the foreach body, invalidateExpression removes the stale conditional from $finalScope. Then mergeWith calls intersectConditionalExpressions, which only keeps entries in both scopes — correctly dropping the stale link.

Making the paths consistent: The custom filtering was a bespoke reimplementation of intersectConditionalExpressions. Now both paths use the same mechanism: intersectConditionalExpressions.

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug14446 is only tested with inference test but should be with a ruleTest, to ensure no error

  • Strict comparison using === between false and true will always evaluate to false.
  • Foreach overwrites $key with its key variable

is reported

Add StrictComparisonOfDifferentTypesRule test with
polluteScopeWithAlwaysIterableForeach: false to ensure no
"Strict comparison using === between false and true" error.

Add integration-style rule test in Bug14446Test that runs full
analysis with OverwriteVariablesWithForeachRule registered,
verifying no "Foreach overwrites $key" false positive.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

All 11,831 tests pass (79,340 assertions, 93 skipped). Both make phpstan and make tests are green. The commit has been pushed.

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deduplicate the bug-14446.php by using the same for both the typeInference and rule test.

Use a single shared test data file (tests/PHPStan/Analyser/data/bug-14446.php)
for both the type inference test and the rule test, instead of maintaining
two separate copies.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Done. The changes:

  • Deleted tests/PHPStan/Rules/Comparison/data/bug-14446.php (the duplicate)
  • Updated tests/PHPStan/Analyser/data/bug-14446.php to add var_dump($initial === true) calls needed by the rule test
  • Updated StrictComparisonOfDifferentTypesRuleTest.php to point to __DIR__ . '/../../Analyser/data/bug-14446.php' instead of the now-deleted local copy

Both tests use the same shared file. All 11,830 tests pass and make phpstan reports no errors.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants